iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 11
3

本文同步更新於blog

需求一:客戶想要一台收銀機

<?php

namespace App\StrategyPattern\CashRegister;

class Program
{
    /**
     * @var int
     */
    private $originalPrice;

    public function __construct($originalPrice)
    {
        $this->originalPrice = $originalPrice;
    }

    public function pay()
    {
        return $this->originalPrice;
    }
}


需求二:客戶想要有一個優惠活動 (打8折)

<?php

namespace App\StrategyPattern\CashRegister;

class Program
{
    /**
     * @var int
     */
    private $originalPrice;

    /**
     * @var string
     */
    private $promotion;

    public function __construct($originalPrice, $promotion)
    {
        $this->originalPrice = $originalPrice;
        $this->promotion = $promotion;
    }

    public function pay()
    {
        if ($this->promotion == '20% off') {
            return $this->originalPrice * 0.8;
        }

        return $this->originalPrice;
    }
}

需求三:客戶想要有另一個優惠 (買300回饋100)

  • 身為工程師的我們,二話不說加上了這個功能
public function pay()
{
    $originalPrice = $this->originalPrice;

    if ($this->promotion == '20% off') {
        return $originalPrice * 0.8;
    }

    if ($this->promotion == 'spend_300_feedback_100') {
        if ($originalPrice >= 300) {
            return $originalPrice - floor($originalPrice / 300) * 100;
        }
    }

    return $originalPrice;
}

這時候功能是完成了,但有沒有覺得哪裡怪怪的?
欸嘿,我們想到之前學過的簡單工廠模式

可以實作三個類別,分別是正常付費、8折付費、買300回饋100。
讓我們利用簡單工廠改造它。


  • 首先定義付錢介面
<?php

namespace App\StrategyPattern\CashRegister\Contracts;

interface Payable
{
    public function pay();
}
  • 實作正常付費類別
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\Contracts\Payable;

class NormalPay implements Payable
{
    /**
     * @var int
     */
    private $originalPrice;

    public function __construct($originalPrice)
    {
        $this->originalPrice = $originalPrice;
    }

    public function pay()
    {
        return $this->originalPrice;
    }
}
  • 實作打折付費類別
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\Contracts\Payable;

class OffPercentPay implements Payable
{
    /**
     * @var int
     */
    private $originalPrice;

    /**
     * @var double
     */
    private $offPercent;

    public function __construct($originalPrice, $offPercent)
    {
        $this->originalPrice = $originalPrice;
        $this->offPercent = $offPercent;
    }

    public function pay()
    {
        return $this->originalPrice * (1 - $this->offPercent);
    }
}
  • 實作買多少回饋多少類別
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\Contracts\Payable;

class FeedbackPay implements Payable
{
    /**
     * @var int
     */
    private $originalPrice;

    /**
     * @var int
     */
    private $priceCondition;

    /**
     * @var int
     */
    private $feedback;

    public function __construct($originalPrice, $priceCondition, $feedback)
    {
        $this->originalPrice = $originalPrice;
        $this->priceCondition = $priceCondition;
        $this->feedback = $feedback;
    }

    public function pay()
    {
        $originalPrice = $this->originalPrice;
        $priceCondition = $this->priceCondition;
        $feedback = $this->feedback;

        if ($originalPrice >= $priceCondition) {
            return $originalPrice - floor($originalPrice / $priceCondition) * $feedback;
        }

        return $originalPrice;
    }
}

最後原本程式再搭配工廠即可完成。 (下略)

正當我們洋洋得意的時候,客戶送來第四個需求...


需求四:客戶希望收銀機可以開一般發票或電子發票

不是啊,客戶你要這種發票類型的需求你要先說.. (碎念)

按照簡單工廠模式的思維,
我們必須為這個需求做出6個類別,
分別是(正常付費、打折付費、買多少回饋多少)x(一般發票、電子發票)的排列組合。

不行不行,假設客戶將來又提出要開統編的需求,我們就要寫8個類別了。

而且這樣也違反開放封閉原則
每次有新需求都會改動所有的程式碼。


在我們研究一下後,發現了一個適合的設計模式:策略模式

  • 我們首先製作一個消費明細類別,它擁有所有的優惠方式
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\OffPercentPay;
use App\StrategyPattern\CashRegister\FeedbackPay;
use App\StrategyPattern\CashRegister\NormalPay;

use App\StrategyPattern\CashRegister\Contracts\Payable;

class CashContext
{
    /**
     * @var Payable
     */
    private $discountMethod;

    /**
     * @param int $originalPrice
     * @param string $discountType
     */
    public function __construct($originalPrice, $discountType)
    {
        $this->resolveDiscountMethod($originalPrice, $discountType);
    }

    /**
     * @param int $originalPrice
     * @param string $discountType
     */
    private function resolveDiscountMethod($originalPrice, $discountType)
    {
        switch ($discountType) {
            case '20% off':
                $this->discountMethod = new OffPercentPay($originalPrice, 0.2);
                break;

            case 'spend_300_feedback_100':
                $this->discountMethod = new FeedbackPay($originalPrice, 300, 100);
                break;

            default:
                $this->discountMethod = new NormalPay($originalPrice);
                break;
        }
    }

    public function pay()
    {
        return $this->discountMethod->pay();
    }

}
  • 再來修改Program,讓它呼叫消費明細物件
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\CashContext;

class Program
{
    /**
     * @var CashContext
     */
    private $cashContext;

    /**
     * @param int $originalPrice
     * @param string $discountType
     */
    public function __construct($originalPrice, $discountType)
    {
        $this->cashContext = new CashContext($originalPrice, $discountType);
    }

    public function pay()
    {
        return $this->cashContext->pay();
    }
}

這樣好像還看不出來有什麼好處,我們繼續實作。


  • 定義發票介面
<?php

namespace App\StrategyPattern\CashRegister\Contracts;

interface Receiptable
{
    public function getReceipt();
}
  • 實作一般發票
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\Contracts\Receiptable;

class NormalReceipt implements Receiptable
{
    public function getReceipt()
    {
        return '一般發票';
    }
}
  • 實作電子發票
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\Contracts\Receiptable;

class ElectronicReceipt implements Receiptable
{
    public function getReceipt()
    {
        return '電子發票';
    }
}
  • 修改消費明細類別
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\NormalPay;
use App\StrategyPattern\CashRegister\NormalReceipt;
use App\StrategyPattern\CashRegister\Contracts\Payable;
use App\StrategyPattern\CashRegister\ElectronicReceipt;
use App\StrategyPattern\CashRegister\Contracts\Receiptable;

class CashContext
{
    /**
     * @var Payable
     */
    private $discountMethod;

    /**
     * @var Receiptable
     */
    private $receipt;

    /**
     * @param int $originalPrice
     * @param string $discountType
     * @param string $receiptType
     */
    public function __construct($originalPrice, $discountType, $receiptType)
    {
        $this->resolveDiscountMethod($originalPrice, $discountType);
        $this->resolveReceiptType($receiptType);
    }

    /**
     * @param int $originalPrice
     * @param string $discountType
     */
    private function resolveDiscountMethod($originalPrice, $discountType)
    {
        switch ($discountType) {
            case '20% off':
                $this->discountMethod = new OffPercentPay($originalPrice, 0.2);
                break;

            case 'spend_300_feedback_100':
                $this->discountMethod = new FeedbackPay($originalPrice, 300, 100);
                break;

            default:
                $this->discountMethod = new NormalPay($originalPrice);
                break;
        }
    }

    /**
     * @param string $receiptType
     */
    private function resolveReceiptType($receiptType)
    {
        switch ($receiptType) {
            case 'electronicReceipt':
                $this->receipt = new ElectronicReceipt();
                break;

            default:
                $this->receipt = new NormalReceipt();
                break;
        }
    }

    public function pay()
    {
        return $this->discountMethod->pay();
    }

    public function getReceipt()
    {
        return $this->receipt->getReceipt();
    }
}
  • 最後修改原本的Program
<?php

namespace App\StrategyPattern\CashRegister;

use App\StrategyPattern\CashRegister\CashContext;

class Program
{
    /**
     * @var CashContext
     */
    private $cashContext;

    /**
     * @param int $originalPrice
     * @param string $discountType
     * @param string $receiptType
     */
    public function __construct($originalPrice, $discountType, $receiptType)
    {
        $this->cashContext = new CashContext($originalPrice, $discountType, $receiptType);
    }

    public function pay()
    {
        return $this->cashContext->pay();
    }

    public function getReceipt()
    {
        return $this->cashContext->getReceipt();
    }
}


[單一職責原則]
類別本身職責算法族的職責分離,就是策略模式的精神!

[開放封閉原則]
這下子,我們終於不會在客戶提出一個新需求時,影響到全部的既有程式碼了。

[介面隔離原則]
定義出付錢介面發票介面,讓兩者不會互相影響。
可以交由各自的算法族,分別實現。

[依賴反轉原則]
消費明細類別依賴抽象的付錢介面與發票介面。
不同的算法族,實現對應的抽象介面。

最後附上類別圖:
https://ithelp.ithome.com.tw/upload/images/20200924/20111630dkcuVj3Q4i.png

(註:若不熟悉 UML 類別圖,可參考UML類別圖說明。)

ʕ •ᴥ•ʔ:使用策略模式,我們依然會做出許多小類別(算法族/算法),
但因為切分的更細,也就更能因應需求去做變化。


上一篇
Day10. 策略模式
下一篇
Day12. 裝飾者模式
系列文
你終究都要學設計模式的,那為什麼不一開始就學呢?57
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言